code.js•113 kB
// This is the main code file for the Cursor MCP Figma plugin
// It handles Figma API commands
// Plugin state
const state = {
serverPort: 3055, // Default port
};
// Helper function for progress updates
function sendProgressUpdate(
commandId,
commandType,
status,
progress,
totalItems,
processedItems,
message,
payload = null
) {
const update = {
type: "command_progress",
commandId,
commandType,
status,
progress,
totalItems,
processedItems,
message,
timestamp: Date.now(),
};
// Add optional chunk information if present
if (payload) {
if (
payload.currentChunk !== undefined &&
payload.totalChunks !== undefined
) {
update.currentChunk = payload.currentChunk;
update.totalChunks = payload.totalChunks;
update.chunkSize = payload.chunkSize;
}
update.payload = payload;
}
// Send to UI
figma.ui.postMessage(update);
console.log(`Progress update: ${status} - ${progress}% - ${message}`);
return update;
}
// Show UI
figma.showUI(__html__, { width: 350, height: 450 });
// Plugin commands from UI
figma.ui.onmessage = async (msg) => {
switch (msg.type) {
case "update-settings":
updateSettings(msg);
break;
case "notify":
figma.notify(msg.message);
break;
case "close-plugin":
figma.closePlugin();
break;
case "execute-command":
// Execute commands received from UI (which gets them from WebSocket)
try {
const result = await handleCommand(msg.command, msg.params);
// Send result back to UI
figma.ui.postMessage({
type: "command-result",
id: msg.id,
result,
});
} catch (error) {
figma.ui.postMessage({
type: "command-error",
id: msg.id,
error: error.message || "Error executing command",
});
}
break;
case "get-selection":
const selection = await getSelection();
figma.ui.postMessage({ type: "selection-info", selection });
break;
}
};
// Listen for plugin commands from menu
figma.on("run", ({ command }) => {
figma.ui.postMessage({ type: "auto-connect" });
});
// Update plugin settings
function updateSettings(settings) {
if (settings.serverPort) {
state.serverPort = settings.serverPort;
}
figma.clientStorage.setAsync("settings", {
serverPort: state.serverPort,
});
}
// Handle commands from UI
async function handleCommand(command, params) {
switch (command) {
case "get_document_info":
return await getDocumentInfo();
case "get_selection":
return await getSelection();
case "get_node_info":
if (!params || !params.nodeId) {
throw new Error("Missing nodeId parameter");
}
return await getNodeInfo(params.nodeId);
case "get_nodes_info":
if (!params || !params.nodeIds || !Array.isArray(params.nodeIds)) {
throw new Error("Missing or invalid nodeIds parameter");
}
return await getNodesInfo(params.nodeIds);
case "read_my_design":
return await readMyDesign();
case "create_rectangle":
return await createRectangle(params);
case "create_frame":
return await createFrame(params);
case "create_text":
return await createText(params);
case "set_fill_color":
return await setFillColor(params);
case "set_stroke_color":
return await setStrokeColor(params);
case "move_node":
return await moveNode(params);
case "resize_node":
return await resizeNode(params);
case "delete_node":
return await deleteNode(params);
case "delete_multiple_nodes":
return await deleteMultipleNodes(params);
case "get_styles":
return await getStyles();
case "get_local_components":
return await getLocalComponents();
// case "get_team_components":
// return await getTeamComponents();
case "create_component_instance":
return await createComponentInstance(params);
case "export_node_as_image":
return await exportNodeAsImage(params);
case "set_corner_radius":
return await setCornerRadius(params);
case "set_text_content":
return await setTextContent(params);
case "clone_node":
return await cloneNode(params);
case "scan_text_nodes":
return await scanTextNodes(params);
case "set_multiple_text_contents":
return await setMultipleTextContents(params);
case "get_annotations":
return await getAnnotations(params);
case "set_annotation":
return await setAnnotation(params);
case "scan_nodes_by_types":
return await scanNodesByTypes(params);
case "set_multiple_annotations":
return await setMultipleAnnotations(params);
case "get_instance_overrides":
// Check if instanceNode parameter is provided
if (params && params.instanceNodeId) {
// Get the instance node by ID
const instanceNode = await figma.getNodeByIdAsync(params.instanceNodeId);
if (!instanceNode) {
throw new Error(`Instance node not found with ID: ${params.instanceNodeId}`);
}
return await getInstanceOverrides(instanceNode);
}
// Call without instance node if not provided
return await getInstanceOverrides();
case "set_instance_overrides":
// Check if instanceNodeIds parameter is provided
if (params && params.targetNodeIds) {
// Validate that targetNodeIds is an array
if (!Array.isArray(params.targetNodeIds)) {
throw new Error("targetNodeIds must be an array");
}
// Get the instance nodes by IDs
const targetNodes = await getValidTargetInstances(params.targetNodeIds);
if (!targetNodes.success) {
figma.notify(targetNodes.message);
return { success: false, message: targetNodes.message };
}
if (params.sourceInstanceId) {
// get source instance data
let sourceInstanceData = null;
sourceInstanceData = await getSourceInstanceData(params.sourceInstanceId);
if (!sourceInstanceData.success) {
figma.notify(sourceInstanceData.message);
return { success: false, message: sourceInstanceData.message };
}
return await setInstanceOverrides(targetNodes.targetInstances, sourceInstanceData);
} else {
throw new Error("Missing sourceInstanceId parameter");
}
}
case "set_layout_mode":
return await setLayoutMode(params);
case "set_padding":
return await setPadding(params);
case "set_axis_align":
return await setAxisAlign(params);
case "set_layout_sizing":
return await setLayoutSizing(params);
case "set_item_spacing":
return await setItemSpacing(params);
case "get_reactions":
if (!params || !params.nodeIds || !Array.isArray(params.nodeIds)) {
throw new Error("Missing or invalid nodeIds parameter");
}
return await getReactions(params.nodeIds);
case "set_default_connector":
return await setDefaultConnector(params);
case "create_connections":
return await createConnections(params);
default:
throw new Error(`Unknown command: ${command}`);
}
}
// Command implementations
async function getDocumentInfo() {
await figma.currentPage.loadAsync();
const page = figma.currentPage;
return {
name: page.name,
id: page.id,
type: page.type,
children: page.children.map((node) => ({
id: node.id,
name: node.name,
type: node.type,
})),
currentPage: {
id: page.id,
name: page.name,
childCount: page.children.length,
},
pages: [
{
id: page.id,
name: page.name,
childCount: page.children.length,
},
],
};
}
async function getSelection() {
return {
selectionCount: figma.currentPage.selection.length,
selection: figma.currentPage.selection.map((node) => ({
id: node.id,
name: node.name,
type: node.type,
visible: node.visible,
})),
};
}
function rgbaToHex(color) {
var r = Math.round(color.r * 255);
var g = Math.round(color.g * 255);
var b = Math.round(color.b * 255);
var a = color.a !== undefined ? Math.round(color.a * 255) : 255;
if (a === 255) {
return (
"#" +
[r, g, b]
.map((x) => {
return x.toString(16).padStart(2, "0");
})
.join("")
);
}
return (
"#" +
[r, g, b, a]
.map((x) => {
return x.toString(16).padStart(2, "0");
})
.join("")
);
}
function filterFigmaNode(node) {
if (node.type === "VECTOR") {
return null;
}
var filtered = {
id: node.id,
name: node.name,
type: node.type,
};
if (node.fills && node.fills.length > 0) {
filtered.fills = node.fills.map((fill) => {
var processedFill = Object.assign({}, fill);
delete processedFill.boundVariables;
delete processedFill.imageRef;
if (processedFill.gradientStops) {
processedFill.gradientStops = processedFill.gradientStops.map(
(stop) => {
var processedStop = Object.assign({}, stop);
if (processedStop.color) {
processedStop.color = rgbaToHex(processedStop.color);
}
delete processedStop.boundVariables;
return processedStop;
}
);
}
if (processedFill.color) {
processedFill.color = rgbaToHex(processedFill.color);
}
return processedFill;
});
}
if (node.strokes && node.strokes.length > 0) {
filtered.strokes = node.strokes.map((stroke) => {
var processedStroke = Object.assign({}, stroke);
delete processedStroke.boundVariables;
if (processedStroke.color) {
processedStroke.color = rgbaToHex(processedStroke.color);
}
return processedStroke;
});
}
if (node.cornerRadius !== undefined) {
filtered.cornerRadius = node.cornerRadius;
}
if (node.absoluteBoundingBox) {
filtered.absoluteBoundingBox = node.absoluteBoundingBox;
}
if (node.characters) {
filtered.characters = node.characters;
}
if (node.style) {
filtered.style = {
fontFamily: node.style.fontFamily,
fontStyle: node.style.fontStyle,
fontWeight: node.style.fontWeight,
fontSize: node.style.fontSize,
textAlignHorizontal: node.style.textAlignHorizontal,
letterSpacing: node.style.letterSpacing,
lineHeightPx: node.style.lineHeightPx,
};
}
if (node.children) {
filtered.children = node.children
.map((child) => {
return filterFigmaNode(child);
})
.filter((child) => {
return child !== null;
});
}
return filtered;
}
async function getNodeInfo(nodeId) {
const node = await figma.getNodeByIdAsync(nodeId);
if (!node) {
throw new Error(`Node not found with ID: ${nodeId}`);
}
const response = await node.exportAsync({
format: "JSON_REST_V1",
});
return filterFigmaNode(response.document);
}
async function getNodesInfo(nodeIds) {
try {
// Load all nodes in parallel
const nodes = await Promise.all(
nodeIds.map((id) => figma.getNodeByIdAsync(id))
);
// Filter out any null values (nodes that weren't found)
const validNodes = nodes.filter((node) => node !== null);
// Export all valid nodes in parallel
const responses = await Promise.all(
validNodes.map(async (node) => {
const response = await node.exportAsync({
format: "JSON_REST_V1",
});
return {
nodeId: node.id,
document: filterFigmaNode(response.document),
};
})
);
return responses;
} catch (error) {
throw new Error(`Error getting nodes info: ${error.message}`);
}
}
async function getReactions(nodeIds) {
try {
const commandId = generateCommandId();
sendProgressUpdate(
commandId,
"get_reactions",
"started",
0,
nodeIds.length,
0,
`Starting deep search for reactions in ${nodeIds.length} nodes and their children`
);
// Function to find nodes with reactions from the node and all its children
async function findNodesWithReactions(node, processedNodes = new Set(), depth = 0, results = []) {
// Skip already processed nodes (prevent circular references)
if (processedNodes.has(node.id)) {
return results;
}
processedNodes.add(node.id);
// Check if the current node has reactions
let filteredReactions = [];
if (node.reactions && node.reactions.length > 0) {
// Filter out reactions with navigation === 'CHANGE_TO'
filteredReactions = node.reactions.filter(r => {
// Some reactions may have action or actions array
if (r.action && r.action.navigation === 'CHANGE_TO') return false;
if (Array.isArray(r.actions)) {
// If any action in actions array is CHANGE_TO, exclude
return !r.actions.some(a => a.navigation === 'CHANGE_TO');
}
return true;
});
}
const hasFilteredReactions = filteredReactions.length > 0;
// If the node has filtered reactions, add it to results and apply highlight effect
if (hasFilteredReactions) {
results.push({
id: node.id,
name: node.name,
type: node.type,
depth: depth,
hasReactions: true,
reactions: filteredReactions,
path: getNodePath(node)
});
// Apply highlight effect (orange border)
await highlightNodeWithAnimation(node);
}
// If node has children, recursively search them
if (node.children) {
for (const child of node.children) {
await findNodesWithReactions(child, processedNodes, depth + 1, results);
}
}
return results;
}
// Function to apply animated highlight effect to a node
async function highlightNodeWithAnimation(node) {
// Save original stroke properties
const originalStrokeWeight = node.strokeWeight;
const originalStrokes = node.strokes ? [...node.strokes] : [];
try {
// Apply orange border stroke
node.strokeWeight = 4;
node.strokes = [{
type: 'SOLID',
color: { r: 1, g: 0.5, b: 0 }, // Orange color
opacity: 0.8
}];
// Set timeout for animation effect (restore to original after 1.5 seconds)
setTimeout(() => {
try {
// Restore original stroke properties
node.strokeWeight = originalStrokeWeight;
node.strokes = originalStrokes;
} catch (restoreError) {
console.error(`Error restoring node stroke: ${restoreError.message}`);
}
}, 1500);
} catch (highlightError) {
console.error(`Error highlighting node: ${highlightError.message}`);
// Continue even if highlighting fails
}
}
// Get node hierarchy path as a string
function getNodePath(node) {
const path = [];
let current = node;
while (current && current.parent) {
path.unshift(current.name);
current = current.parent;
}
return path.join(' > ');
}
// Array to store all results
let allResults = [];
let processedCount = 0;
const totalCount = nodeIds.length;
// Iterate through each node and its children to search for reactions
for (let i = 0; i < nodeIds.length; i++) {
try {
const nodeId = nodeIds[i];
const node = await figma.getNodeByIdAsync(nodeId);
if (!node) {
processedCount++;
sendProgressUpdate(
commandId,
"get_reactions",
"in_progress",
processedCount / totalCount,
totalCount,
processedCount,
`Node not found: ${nodeId}`
);
continue;
}
// Search for reactions in the node and its children
const processedNodes = new Set();
const nodeResults = await findNodesWithReactions(node, processedNodes);
// Add results
allResults = allResults.concat(nodeResults);
// Update progress
processedCount++;
sendProgressUpdate(
commandId,
"get_reactions",
"in_progress",
processedCount / totalCount,
totalCount,
processedCount,
`Processed node ${processedCount}/${totalCount}, found ${nodeResults.length} nodes with reactions`
);
} catch (error) {
processedCount++;
sendProgressUpdate(
commandId,
"get_reactions",
"in_progress",
processedCount / totalCount,
totalCount,
processedCount,
`Error processing node: ${error.message}`
);
}
}
// Completion update
sendProgressUpdate(
commandId,
"get_reactions",
"completed",
1,
totalCount,
totalCount,
`Completed deep search: found ${allResults.length} nodes with reactions.`
);
return {
nodesCount: nodeIds.length,
nodesWithReactions: allResults.length,
nodes: allResults
};
} catch (error) {
throw new Error(`Failed to get reactions: ${error.message}`);
}
}
async function readMyDesign() {
try {
// Load all selected nodes in parallel
const nodes = await Promise.all(
figma.currentPage.selection.map((node) => figma.getNodeByIdAsync(node.id))
);
// Filter out any null values (nodes that weren't found)
const validNodes = nodes.filter((node) => node !== null);
// Export all valid nodes in parallel
const responses = await Promise.all(
validNodes.map(async (node) => {
const response = await node.exportAsync({
format: "JSON_REST_V1",
});
return {
nodeId: node.id,
document: filterFigmaNode(response.document),
};
})
);
return responses;
} catch (error) {
throw new Error(`Error getting nodes info: ${error.message}`);
}
}
async function createRectangle(params) {
const {
x = 0,
y = 0,
width = 100,
height = 100,
name = "Rectangle",
parentId,
} = params || {};
const rect = figma.createRectangle();
rect.x = x;
rect.y = y;
rect.resize(width, height);
rect.name = name;
// If parentId is provided, append to that node, otherwise append to current page
if (parentId) {
const parentNode = await figma.getNodeByIdAsync(parentId);
if (!parentNode) {
throw new Error(`Parent node not found with ID: ${parentId}`);
}
if (!("appendChild" in parentNode)) {
throw new Error(`Parent node does not support children: ${parentId}`);
}
parentNode.appendChild(rect);
} else {
figma.currentPage.appendChild(rect);
}
return {
id: rect.id,
name: rect.name,
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
parentId: rect.parent ? rect.parent.id : undefined,
};
}
async function createFrame(params) {
const {
x = 0,
y = 0,
width = 100,
height = 100,
name = "Frame",
parentId,
fillColor,
strokeColor,
strokeWeight,
layoutMode = "NONE",
layoutWrap = "NO_WRAP",
paddingTop = 10,
paddingRight = 10,
paddingBottom = 10,
paddingLeft = 10,
primaryAxisAlignItems = "MIN",
counterAxisAlignItems = "MIN",
layoutSizingHorizontal = "FIXED",
layoutSizingVertical = "FIXED",
itemSpacing = 0,
} = params || {};
const frame = figma.createFrame();
frame.x = x;
frame.y = y;
frame.resize(width, height);
frame.name = name;
// Set layout mode if provided
if (layoutMode !== "NONE") {
frame.layoutMode = layoutMode;
frame.layoutWrap = layoutWrap;
// Set padding values only when layoutMode is not NONE
frame.paddingTop = paddingTop;
frame.paddingRight = paddingRight;
frame.paddingBottom = paddingBottom;
frame.paddingLeft = paddingLeft;
// Set axis alignment only when layoutMode is not NONE
frame.primaryAxisAlignItems = primaryAxisAlignItems;
frame.counterAxisAlignItems = counterAxisAlignItems;
// Set layout sizing only when layoutMode is not NONE
frame.layoutSizingHorizontal = layoutSizingHorizontal;
frame.layoutSizingVertical = layoutSizingVertical;
// Set item spacing only when layoutMode is not NONE
frame.itemSpacing = itemSpacing;
}
// Set fill color if provided
if (fillColor) {
const paintStyle = {
type: "SOLID",
color: {
r: parseFloat(fillColor.r) || 0,
g: parseFloat(fillColor.g) || 0,
b: parseFloat(fillColor.b) || 0,
},
opacity: parseFloat(fillColor.a) || 1,
};
frame.fills = [paintStyle];
}
// Set stroke color and weight if provided
if (strokeColor) {
const strokeStyle = {
type: "SOLID",
color: {
r: parseFloat(strokeColor.r) || 0,
g: parseFloat(strokeColor.g) || 0,
b: parseFloat(strokeColor.b) || 0,
},
opacity: parseFloat(strokeColor.a) || 1,
};
frame.strokes = [strokeStyle];
}
// Set stroke weight if provided
if (strokeWeight !== undefined) {
frame.strokeWeight = strokeWeight;
}
// If parentId is provided, append to that node, otherwise append to current page
if (parentId) {
const parentNode = await figma.getNodeByIdAsync(parentId);
if (!parentNode) {
throw new Error(`Parent node not found with ID: ${parentId}`);
}
if (!("appendChild" in parentNode)) {
throw new Error(`Parent node does not support children: ${parentId}`);
}
parentNode.appendChild(frame);
} else {
figma.currentPage.appendChild(frame);
}
return {
id: frame.id,
name: frame.name,
x: frame.x,
y: frame.y,
width: frame.width,
height: frame.height,
fills: frame.fills,
strokes: frame.strokes,
strokeWeight: frame.strokeWeight,
layoutMode: frame.layoutMode,
layoutWrap: frame.layoutWrap,
parentId: frame.parent ? frame.parent.id : undefined,
};
}
async function createText(params) {
const {
x = 0,
y = 0,
text = "Text",
fontSize = 14,
fontWeight = 400,
fontColor = { r: 0, g: 0, b: 0, a: 1 }, // Default to black
name = "",
parentId,
} = params || {};
// Map common font weights to Figma font styles
const getFontStyle = (weight) => {
switch (weight) {
case 100:
return "Thin";
case 200:
return "Extra Light";
case 300:
return "Light";
case 400:
return "Regular";
case 500:
return "Medium";
case 600:
return "Semi Bold";
case 700:
return "Bold";
case 800:
return "Extra Bold";
case 900:
return "Black";
default:
return "Regular";
}
};
const textNode = figma.createText();
textNode.x = x;
textNode.y = y;
textNode.name = name || text;
try {
await figma.loadFontAsync({
family: "Inter",
style: getFontStyle(fontWeight),
});
textNode.fontName = { family: "Inter", style: getFontStyle(fontWeight) };
textNode.fontSize = parseInt(fontSize);
} catch (error) {
console.error("Error setting font size", error);
}
setCharacters(textNode, text);
// Set text color
const paintStyle = {
type: "SOLID",
color: {
r: parseFloat(fontColor.r) || 0,
g: parseFloat(fontColor.g) || 0,
b: parseFloat(fontColor.b) || 0,
},
opacity: parseFloat(fontColor.a) || 1,
};
textNode.fills = [paintStyle];
// If parentId is provided, append to that node, otherwise append to current page
if (parentId) {
const parentNode = await figma.getNodeByIdAsync(parentId);
if (!parentNode) {
throw new Error(`Parent node not found with ID: ${parentId}`);
}
if (!("appendChild" in parentNode)) {
throw new Error(`Parent node does not support children: ${parentId}`);
}
parentNode.appendChild(textNode);
} else {
figma.currentPage.appendChild(textNode);
}
return {
id: textNode.id,
name: textNode.name,
x: textNode.x,
y: textNode.y,
width: textNode.width,
height: textNode.height,
characters: textNode.characters,
fontSize: textNode.fontSize,
fontWeight: fontWeight,
fontColor: fontColor,
fontName: textNode.fontName,
fills: textNode.fills,
parentId: textNode.parent ? textNode.parent.id : undefined,
};
}
async function setFillColor(params) {
console.log("setFillColor", params);
const {
nodeId,
color: { r, g, b, a },
} = params || {};
if (!nodeId) {
throw new Error("Missing nodeId parameter");
}
const node = await figma.getNodeByIdAsync(nodeId);
if (!node) {
throw new Error(`Node not found with ID: ${nodeId}`);
}
if (!("fills" in node)) {
throw new Error(`Node does not support fills: ${nodeId}`);
}
// Create RGBA color
const rgbColor = {
r: parseFloat(r) || 0,
g: parseFloat(g) || 0,
b: parseFloat(b) || 0,
a: parseFloat(a) || 1,
};
// Set fill
const paintStyle = {
type: "SOLID",
color: {
r: parseFloat(rgbColor.r),
g: parseFloat(rgbColor.g),
b: parseFloat(rgbColor.b),
},
opacity: parseFloat(rgbColor.a),
};
console.log("paintStyle", paintStyle);
node.fills = [paintStyle];
return {
id: node.id,
name: node.name,
fills: [paintStyle],
};
}
async function setStrokeColor(params) {
const {
nodeId,
color: { r, g, b, a },
weight = 1,
} = params || {};
if (!nodeId) {
throw new Error("Missing nodeId parameter");
}
const node = await figma.getNodeByIdAsync(nodeId);
if (!node) {
throw new Error(`Node not found with ID: ${nodeId}`);
}
if (!("strokes" in node)) {
throw new Error(`Node does not support strokes: ${nodeId}`);
}
// Create RGBA color
const rgbColor = {
r: r !== undefined ? r : 0,
g: g !== undefined ? g : 0,
b: b !== undefined ? b : 0,
a: a !== undefined ? a : 1,
};
// Set stroke
const paintStyle = {
type: "SOLID",
color: {
r: rgbColor.r,
g: rgbColor.g,
b: rgbColor.b,
},
opacity: rgbColor.a,
};
node.strokes = [paintStyle];
// Set stroke weight if available
if ("strokeWeight" in node) {
node.strokeWeight = weight;
}
return {
id: node.id,
name: node.name,
strokes: node.strokes,
strokeWeight: "strokeWeight" in node ? node.strokeWeight : undefined,
};
}
async function moveNode(params) {
const { nodeId, x, y } = params || {};
if (!nodeId) {
throw new Error("Missing nodeId parameter");
}
if (x === undefined || y === undefined) {
throw new Error("Missing x or y parameters");
}
const node = await figma.getNodeByIdAsync(nodeId);
if (!node) {
throw new Error(`Node not found with ID: ${nodeId}`);
}
if (!("x" in node) || !("y" in node)) {
throw new Error(`Node does not support position: ${nodeId}`);
}
node.x = x;
node.y = y;
return {
id: node.id,
name: node.name,
x: node.x,
y: node.y,
};
}
async function resizeNode(params) {
const { nodeId, width, height } = params || {};
if (!nodeId) {
throw new Error("Missing nodeId parameter");
}
if (width === undefined || height === undefined) {
throw new Error("Missing width or height parameters");
}
const node = await figma.getNodeByIdAsync(nodeId);
if (!node) {
throw new Error(`Node not found with ID: ${nodeId}`);
}
if (!("resize" in node)) {
throw new Error(`Node does not support resizing: ${nodeId}`);
}
node.resize(width, height);
return {
id: node.id,
name: node.name,
width: node.width,
height: node.height,
};
}
async function deleteNode(params) {
const { nodeId } = params || {};
if (!nodeId) {
throw new Error("Missing nodeId parameter");
}
const node = await figma.getNodeByIdAsync(nodeId);
if (!node) {
throw new Error(`Node not found with ID: ${nodeId}`);
}
// Save node info before deleting
const nodeInfo = {
id: node.id,
name: node.name,
type: node.type,
};
node.remove();
return nodeInfo;
}
async function getStyles() {
const styles = {
colors: await figma.getLocalPaintStylesAsync(),
texts: await figma.getLocalTextStylesAsync(),
effects: await figma.getLocalEffectStylesAsync(),
grids: await figma.getLocalGridStylesAsync(),
};
return {
colors: styles.colors.map((style) => ({
id: style.id,
name: style.name,
key: style.key,
paint: style.paints[0],
})),
texts: styles.texts.map((style) => ({
id: style.id,
name: style.name,
key: style.key,
fontSize: style.fontSize,
fontName: style.fontName,
})),
effects: styles.effects.map((style) => ({
id: style.id,
name: style.name,
key: style.key,
})),
grids: styles.grids.map((style) => ({
id: style.id,
name: style.name,
key: style.key,
})),
};
}
async function getLocalComponents() {
await figma.loadAllPagesAsync();
const components = figma.root.findAllWithCriteria({
types: ["COMPONENT"],
});
return {
count: components.length,
components: components.map((component) => ({
id: component.id,
name: component.name,
key: "key" in component ? component.key : null,
})),
};
}
// async function getTeamComponents() {
// try {
// const teamComponents =
// await figma.teamLibrary.getAvailableComponentsAsync();
// return {
// count: teamComponents.length,
// components: teamComponents.map((component) => ({
// key: component.key,
// name: component.name,
// description: component.description,
// libraryName: component.libraryName,
// })),
// };
// } catch (error) {
// throw new Error(`Error getting team components: ${error.message}`);
// }
// }
async function createComponentInstance(params) {
const { componentKey, x = 0, y = 0 } = params || {};
if (!componentKey) {
throw new Error("Missing componentKey parameter");
}
try {
const component = await figma.importComponentByKeyAsync(componentKey);
const instance = component.createInstance();
instance.x = x;
instance.y = y;
figma.currentPage.appendChild(instance);
return {
id: instance.id,
name: instance.name,
x: instance.x,
y: instance.y,
width: instance.width,
height: instance.height,
componentId: instance.componentId,
};
} catch (error) {
throw new Error(`Error creating component instance: ${error.message}`);
}
}
async function exportNodeAsImage(params) {
const { nodeId, scale = 1 } = params || {};
const format = "PNG";
if (!nodeId) {
throw new Error("Missing nodeId parameter");
}
const node = await figma.getNodeByIdAsync(nodeId);
if (!node) {
throw new Error(`Node not found with ID: ${nodeId}`);
}
if (!("exportAsync" in node)) {
throw new Error(`Node does not support exporting: ${nodeId}`);
}
try {
const settings = {
format: format,
constraint: { type: "SCALE", value: scale },
};
const bytes = await node.exportAsync(settings);
let mimeType;
switch (format) {
case "PNG":
mimeType = "image/png";
break;
case "JPG":
mimeType = "image/jpeg";
break;
case "SVG":
mimeType = "image/svg+xml";
break;
case "PDF":
mimeType = "application/pdf";
break;
default:
mimeType = "application/octet-stream";
}
// Proper way to convert Uint8Array to base64
const base64 = customBase64Encode(bytes);
// const imageData = `data:${mimeType};base64,${base64}`;
return {
nodeId,
format,
scale,
mimeType,
imageData: base64,
};
} catch (error) {
throw new Error(`Error exporting node as image: ${error.message}`);
}
}
function customBase64Encode(bytes) {
const chars =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let base64 = "";
const byteLength = bytes.byteLength;
const byteRemainder = byteLength % 3;
const mainLength = byteLength - byteRemainder;
let a, b, c, d;
let chunk;
// Main loop deals with bytes in chunks of 3
for (let i = 0; i < mainLength; i = i + 3) {
// Combine the three bytes into a single integer
chunk = (bytes[i] << 16) | (bytes[i + 1] << 8) | bytes[i + 2];
// Use bitmasks to extract 6-bit segments from the triplet
a = (chunk & 16515072) >> 18; // 16515072 = (2^6 - 1) << 18
b = (chunk & 258048) >> 12; // 258048 = (2^6 - 1) << 12
c = (chunk & 4032) >> 6; // 4032 = (2^6 - 1) << 6
d = chunk & 63; // 63 = 2^6 - 1
// Convert the raw binary segments to the appropriate ASCII encoding
base64 += chars[a] + chars[b] + chars[c] + chars[d];
}
// Deal with the remaining bytes and padding
if (byteRemainder === 1) {
chunk = bytes[mainLength];
a = (chunk & 252) >> 2; // 252 = (2^6 - 1) << 2
// Set the 4 least significant bits to zero
b = (chunk & 3) << 4; // 3 = 2^2 - 1
base64 += chars[a] + chars[b] + "==";
} else if (byteRemainder === 2) {
chunk = (bytes[mainLength] << 8) | bytes[mainLength + 1];
a = (chunk & 64512) >> 10; // 64512 = (2^6 - 1) << 10
b = (chunk & 1008) >> 4; // 1008 = (2^6 - 1) << 4
// Set the 2 least significant bits to zero
c = (chunk & 15) << 2; // 15 = 2^4 - 1
base64 += chars[a] + chars[b] + chars[c] + "=";
}
return base64;
}
async function setCornerRadius(params) {
const { nodeId, radius, corners } = params || {};
if (!nodeId) {
throw new Error("Missing nodeId parameter");
}
if (radius === undefined) {
throw new Error("Missing radius parameter");
}
const node = await figma.getNodeByIdAsync(nodeId);
if (!node) {
throw new Error(`Node not found with ID: ${nodeId}`);
}
// Check if node supports corner radius
if (!("cornerRadius" in node)) {
throw new Error(`Node does not support corner radius: ${nodeId}`);
}
// If corners array is provided, set individual corner radii
if (corners && Array.isArray(corners) && corners.length === 4) {
if ("topLeftRadius" in node) {
// Node supports individual corner radii
if (corners[0]) node.topLeftRadius = radius;
if (corners[1]) node.topRightRadius = radius;
if (corners[2]) node.bottomRightRadius = radius;
if (corners[3]) node.bottomLeftRadius = radius;
} else {
// Node only supports uniform corner radius
node.cornerRadius = radius;
}
} else {
// Set uniform corner radius
node.cornerRadius = radius;
}
return {
id: node.id,
name: node.name,
cornerRadius: "cornerRadius" in node ? node.cornerRadius : undefined,
topLeftRadius: "topLeftRadius" in node ? node.topLeftRadius : undefined,
topRightRadius: "topRightRadius" in node ? node.topRightRadius : undefined,
bottomRightRadius:
"bottomRightRadius" in node ? node.bottomRightRadius : undefined,
bottomLeftRadius:
"bottomLeftRadius" in node ? node.bottomLeftRadius : undefined,
};
}
async function setTextContent(params) {
const { nodeId, text } = params || {};
if (!nodeId) {
throw new Error("Missing nodeId parameter");
}
if (text === undefined) {
throw new Error("Missing text parameter");
}
const node = await figma.getNodeByIdAsync(nodeId);
if (!node) {
throw new Error(`Node not found with ID: ${nodeId}`);
}
if (node.type !== "TEXT") {
throw new Error(`Node is not a text node: ${nodeId}`);
}
try {
await figma.loadFontAsync(node.fontName);
await setCharacters(node, text);
return {
id: node.id,
name: node.name,
characters: node.characters,
fontName: node.fontName,
};
} catch (error) {
throw new Error(`Error setting text content: ${error.message}`);
}
}
// Initialize settings on load
(async function initializePlugin() {
try {
const savedSettings = await figma.clientStorage.getAsync("settings");
if (savedSettings) {
if (savedSettings.serverPort) {
state.serverPort = savedSettings.serverPort;
}
}
// Send initial settings to UI
figma.ui.postMessage({
type: "init-settings",
settings: {
serverPort: state.serverPort,
},
});
} catch (error) {
console.error("Error loading settings:", error);
}
})();
function uniqBy(arr, predicate) {
const cb = typeof predicate === "function" ? predicate : (o) => o[predicate];
return [
...arr
.reduce((map, item) => {
const key = item === null || item === undefined ? item : cb(item);
map.has(key) || map.set(key, item);
return map;
}, new Map())
.values(),
];
}
const setCharacters = async (node, characters, options) => {
const fallbackFont = (options && options.fallbackFont) || {
family: "Inter",
style: "Regular",
};
try {
if (node.fontName === figma.mixed) {
if (options && options.smartStrategy === "prevail") {
const fontHashTree = {};
for (let i = 1; i < node.characters.length; i++) {
const charFont = node.getRangeFontName(i - 1, i);
const key = `${charFont.family}::${charFont.style}`;
fontHashTree[key] = fontHashTree[key] ? fontHashTree[key] + 1 : 1;
}
const prevailedTreeItem = Object.entries(fontHashTree).sort(
(a, b) => b[1] - a[1]
)[0];
const [family, style] = prevailedTreeItem[0].split("::");
const prevailedFont = {
family,
style,
};
await figma.loadFontAsync(prevailedFont);
node.fontName = prevailedFont;
} else if (options && options.smartStrategy === "strict") {
return setCharactersWithStrictMatchFont(node, characters, fallbackFont);
} else if (options && options.smartStrategy === "experimental") {
return setCharactersWithSmartMatchFont(node, characters, fallbackFont);
} else {
const firstCharFont = node.getRangeFontName(0, 1);
await figma.loadFontAsync(firstCharFont);
node.fontName = firstCharFont;
}
} else {
await figma.loadFontAsync({
family: node.fontName.family,
style: node.fontName.style,
});
}
} catch (err) {
console.warn(
`Failed to load "${node.fontName["family"]} ${node.fontName["style"]}" font and replaced with fallback "${fallbackFont.family} ${fallbackFont.style}"`,
err
);
await figma.loadFontAsync(fallbackFont);
node.fontName = fallbackFont;
}
try {
node.characters = characters;
return true;
} catch (err) {
console.warn(`Failed to set characters. Skipped.`, err);
return false;
}
};
const setCharactersWithStrictMatchFont = async (
node,
characters,
fallbackFont
) => {
const fontHashTree = {};
for (let i = 1; i < node.characters.length; i++) {
const startIdx = i - 1;
const startCharFont = node.getRangeFontName(startIdx, i);
const startCharFontVal = `${startCharFont.family}::${startCharFont.style}`;
while (i < node.characters.length) {
i++;
const charFont = node.getRangeFontName(i - 1, i);
if (startCharFontVal !== `${charFont.family}::${charFont.style}`) {
break;
}
}
fontHashTree[`${startIdx}_${i}`] = startCharFontVal;
}
await figma.loadFontAsync(fallbackFont);
node.fontName = fallbackFont;
node.characters = characters;
console.log(fontHashTree);
await Promise.all(
Object.keys(fontHashTree).map(async (range) => {
console.log(range, fontHashTree[range]);
const [start, end] = range.split("_");
const [family, style] = fontHashTree[range].split("::");
const matchedFont = {
family,
style,
};
await figma.loadFontAsync(matchedFont);
return node.setRangeFontName(Number(start), Number(end), matchedFont);
})
);
return true;
};
const getDelimiterPos = (str, delimiter, startIdx = 0, endIdx = str.length) => {
const indices = [];
let temp = startIdx;
for (let i = startIdx; i < endIdx; i++) {
if (
str[i] === delimiter &&
i + startIdx !== endIdx &&
temp !== i + startIdx
) {
indices.push([temp, i + startIdx]);
temp = i + startIdx + 1;
}
}
temp !== endIdx && indices.push([temp, endIdx]);
return indices.filter(Boolean);
};
const buildLinearOrder = (node) => {
const fontTree = [];
const newLinesPos = getDelimiterPos(node.characters, "\n");
newLinesPos.forEach(([newLinesRangeStart, newLinesRangeEnd], n) => {
const newLinesRangeFont = node.getRangeFontName(
newLinesRangeStart,
newLinesRangeEnd
);
if (newLinesRangeFont === figma.mixed) {
const spacesPos = getDelimiterPos(
node.characters,
" ",
newLinesRangeStart,
newLinesRangeEnd
);
spacesPos.forEach(([spacesRangeStart, spacesRangeEnd], s) => {
const spacesRangeFont = node.getRangeFontName(
spacesRangeStart,
spacesRangeEnd
);
if (spacesRangeFont === figma.mixed) {
const spacesRangeFont = node.getRangeFontName(
spacesRangeStart,
spacesRangeStart[0]
);
fontTree.push({
start: spacesRangeStart,
delimiter: " ",
family: spacesRangeFont.family,
style: spacesRangeFont.style,
});
} else {
fontTree.push({
start: spacesRangeStart,
delimiter: " ",
family: spacesRangeFont.family,
style: spacesRangeFont.style,
});
}
});
} else {
fontTree.push({
start: newLinesRangeStart,
delimiter: "\n",
family: newLinesRangeFont.family,
style: newLinesRangeFont.style,
});
}
});
return fontTree
.sort((a, b) => +a.start - +b.start)
.map(({ family, style, delimiter }) => ({ family, style, delimiter }));
};
const setCharactersWithSmartMatchFont = async (
node,
characters,
fallbackFont
) => {
const rangeTree = buildLinearOrder(node);
const fontsToLoad = uniqBy(
rangeTree,
({ family, style }) => `${family}::${style}`
).map(({ family, style }) => ({
family,
style,
}));
await Promise.all([...fontsToLoad, fallbackFont].map(figma.loadFontAsync));
node.fontName = fallbackFont;
node.characters = characters;
let prevPos = 0;
rangeTree.forEach(({ family, style, delimiter }) => {
if (prevPos < node.characters.length) {
const delimeterPos = node.characters.indexOf(delimiter, prevPos);
const endPos =
delimeterPos > prevPos ? delimeterPos : node.characters.length;
const matchedFont = {
family,
style,
};
node.setRangeFontName(prevPos, endPos, matchedFont);
prevPos = endPos + 1;
}
});
return true;
};
// Add the cloneNode function implementation
async function cloneNode(params) {
const { nodeId, x, y } = params || {};
if (!nodeId) {
throw new Error("Missing nodeId parameter");
}
const node = await figma.getNodeByIdAsync(nodeId);
if (!node) {
throw new Error(`Node not found with ID: ${nodeId}`);
}
// Clone the node
const clone = node.clone();
// If x and y are provided, move the clone to that position
if (x !== undefined && y !== undefined) {
if (!("x" in clone) || !("y" in clone)) {
throw new Error(`Cloned node does not support position: ${nodeId}`);
}
clone.x = x;
clone.y = y;
}
// Add the clone to the same parent as the original node
if (node.parent) {
node.parent.appendChild(clone);
} else {
figma.currentPage.appendChild(clone);
}
return {
id: clone.id,
name: clone.name,
x: "x" in clone ? clone.x : undefined,
y: "y" in clone ? clone.y : undefined,
width: "width" in clone ? clone.width : undefined,
height: "height" in clone ? clone.height : undefined,
};
}
async function scanTextNodes(params) {
console.log(`Starting to scan text nodes from node ID: ${params.nodeId}`);
const {
nodeId,
useChunking = true,
chunkSize = 10,
commandId = generateCommandId(),
} = params || {};
const node = await figma.getNodeByIdAsync(nodeId);
if (!node) {
console.error(`Node with ID ${nodeId} not found`);
// Send error progress update
sendProgressUpdate(
commandId,
"scan_text_nodes",
"error",
0,
0,
0,
`Node with ID ${nodeId} not found`,
{ error: `Node not found: ${nodeId}` }
);
throw new Error(`Node with ID ${nodeId} not found`);
}
// If chunking is not enabled, use the original implementation
if (!useChunking) {
const textNodes = [];
try {
// Send started progress update
sendProgressUpdate(
commandId,
"scan_text_nodes",
"started",
0,
1, // Not known yet how many nodes there are
0,
`Starting scan of node "${node.name || nodeId}" without chunking`,
null
);
await findTextNodes(node, [], 0, textNodes);
// Send completed progress update
sendProgressUpdate(
commandId,
"scan_text_nodes",
"completed",
100,
textNodes.length,
textNodes.length,
`Scan complete. Found ${textNodes.length} text nodes.`,
{ textNodes }
);
return {
success: true,
message: `Scanned ${textNodes.length} text nodes.`,
count: textNodes.length,
textNodes: textNodes,
commandId,
};
} catch (error) {
console.error("Error scanning text nodes:", error);
// Send error progress update
sendProgressUpdate(
commandId,
"scan_text_nodes",
"error",
0,
0,
0,
`Error scanning text nodes: ${error.message}`,
{ error: error.message }
);
throw new Error(`Error scanning text nodes: ${error.message}`);
}
}
// Chunked implementation
console.log(`Using chunked scanning with chunk size: ${chunkSize}`);
// First, collect all nodes to process (without processing them yet)
const nodesToProcess = [];
// Send started progress update
sendProgressUpdate(
commandId,
"scan_text_nodes",
"started",
0,
0, // Not known yet how many nodes there are
0,
`Starting chunked scan of node "${node.name || nodeId}"`,
{ chunkSize }
);
await collectNodesToProcess(node, [], 0, nodesToProcess);
const totalNodes = nodesToProcess.length;
console.log(`Found ${totalNodes} total nodes to process`);
// Calculate number of chunks needed
const totalChunks = Math.ceil(totalNodes / chunkSize);
console.log(`Will process in ${totalChunks} chunks`);
// Send update after node collection
sendProgressUpdate(
commandId,
"scan_text_nodes",
"in_progress",
5, // 5% progress for collection phase
totalNodes,
0,
`Found ${totalNodes} nodes to scan. Will process in ${totalChunks} chunks.`,
{
totalNodes,
totalChunks,
chunkSize,
}
);
// Process nodes in chunks
const allTextNodes = [];
let processedNodes = 0;
let chunksProcessed = 0;
for (let i = 0; i < totalNodes; i += chunkSize) {
const chunkEnd = Math.min(i + chunkSize, totalNodes);
console.log(
`Processing chunk ${chunksProcessed + 1}/${totalChunks} (nodes ${i} to ${chunkEnd - 1
})`
);
// Send update before processing chunk
sendProgressUpdate(
commandId,
"scan_text_nodes",
"in_progress",
Math.round(5 + (chunksProcessed / totalChunks) * 90), // 5-95% for processing
totalNodes,
processedNodes,
`Processing chunk ${chunksProcessed + 1}/${totalChunks}`,
{
currentChunk: chunksProcessed + 1,
totalChunks,
textNodesFound: allTextNodes.length,
}
);
const chunkNodes = nodesToProcess.slice(i, chunkEnd);
const chunkTextNodes = [];
// Process each node in this chunk
for (const nodeInfo of chunkNodes) {
if (nodeInfo.node.type === "TEXT") {
try {
const textNodeInfo = await processTextNode(
nodeInfo.node,
nodeInfo.parentPath,
nodeInfo.depth
);
if (textNodeInfo) {
chunkTextNodes.push(textNodeInfo);
}
} catch (error) {
console.error(`Error processing text node: ${error.message}`);
// Continue with other nodes
}
}
// Brief delay to allow UI updates and prevent freezing
await delay(5);
}
// Add results from this chunk
allTextNodes.push(...chunkTextNodes);
processedNodes += chunkNodes.length;
chunksProcessed++;
// Send update after processing chunk
sendProgressUpdate(
commandId,
"scan_text_nodes",
"in_progress",
Math.round(5 + (chunksProcessed / totalChunks) * 90), // 5-95% for processing
totalNodes,
processedNodes,
`Processed chunk ${chunksProcessed}/${totalChunks}. Found ${allTextNodes.length} text nodes so far.`,
{
currentChunk: chunksProcessed,
totalChunks,
processedNodes,
textNodesFound: allTextNodes.length,
chunkResult: chunkTextNodes,
}
);
// Small delay between chunks to prevent UI freezing
if (i + chunkSize < totalNodes) {
await delay(50);
}
}
// Send completed progress update
sendProgressUpdate(
commandId,
"scan_text_nodes",
"completed",
100,
totalNodes,
processedNodes,
`Scan complete. Found ${allTextNodes.length} text nodes.`,
{
textNodes: allTextNodes,
processedNodes,
chunks: chunksProcessed,
}
);
return {
success: true,
message: `Chunked scan complete. Found ${allTextNodes.length} text nodes.`,
totalNodes: allTextNodes.length,
processedNodes: processedNodes,
chunks: chunksProcessed,
textNodes: allTextNodes,
commandId,
};
}
// Helper function to collect all nodes that need to be processed
async function collectNodesToProcess(
node,
parentPath = [],
depth = 0,
nodesToProcess = []
) {
// Skip invisible nodes
if (node.visible === false) return;
// Get the path to this node
const nodePath = [...parentPath, node.name || `Unnamed ${node.type}`];
// Add this node to the processing list
nodesToProcess.push({
node: node,
parentPath: nodePath,
depth: depth,
});
// Recursively add children
if ("children" in node) {
for (const child of node.children) {
await collectNodesToProcess(child, nodePath, depth + 1, nodesToProcess);
}
}
}
// Process a single text node
async function processTextNode(node, parentPath, depth) {
if (node.type !== "TEXT") return null;
try {
// Safely extract font information
let fontFamily = "";
let fontStyle = "";
if (node.fontName) {
if (typeof node.fontName === "object") {
if ("family" in node.fontName) fontFamily = node.fontName.family;
if ("style" in node.fontName) fontStyle = node.fontName.style;
}
}
// Create a safe representation of the text node
const safeTextNode = {
id: node.id,
name: node.name || "Text",
type: node.type,
characters: node.characters,
fontSize: typeof node.fontSize === "number" ? node.fontSize : 0,
fontFamily: fontFamily,
fontStyle: fontStyle,
x: typeof node.x === "number" ? node.x : 0,
y: typeof node.y === "number" ? node.y : 0,
width: typeof node.width === "number" ? node.width : 0,
height: typeof node.height === "number" ? node.height : 0,
path: parentPath.join(" > "),
depth: depth,
};
// Highlight the node briefly (optional visual feedback)
try {
const originalFills = JSON.parse(JSON.stringify(node.fills));
node.fills = [
{
type: "SOLID",
color: { r: 1, g: 0.5, b: 0 },
opacity: 0.3,
},
];
// Brief delay for the highlight to be visible
await delay(100);
try {
node.fills = originalFills;
} catch (err) {
console.error("Error resetting fills:", err);
}
} catch (highlightErr) {
console.error("Error highlighting text node:", highlightErr);
// Continue anyway, highlighting is just visual feedback
}
return safeTextNode;
} catch (nodeErr) {
console.error("Error processing text node:", nodeErr);
return null;
}
}
// A delay function that returns a promise
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// Keep the original findTextNodes for backward compatibility
async function findTextNodes(node, parentPath = [], depth = 0, textNodes = []) {
// Skip invisible nodes
if (node.visible === false) return;
// Get the path to this node including its name
const nodePath = [...parentPath, node.name || `Unnamed ${node.type}`];
if (node.type === "TEXT") {
try {
// Safely extract font information to avoid Symbol serialization issues
let fontFamily = "";
let fontStyle = "";
if (node.fontName) {
if (typeof node.fontName === "object") {
if ("family" in node.fontName) fontFamily = node.fontName.family;
if ("style" in node.fontName) fontStyle = node.fontName.style;
}
}
// Create a safe representation of the text node with only serializable properties
const safeTextNode = {
id: node.id,
name: node.name || "Text",
type: node.type,
characters: node.characters,
fontSize: typeof node.fontSize === "number" ? node.fontSize : 0,
fontFamily: fontFamily,
fontStyle: fontStyle,
x: typeof node.x === "number" ? node.x : 0,
y: typeof node.y === "number" ? node.y : 0,
width: typeof node.width === "number" ? node.width : 0,
height: typeof node.height === "number" ? node.height : 0,
path: nodePath.join(" > "),
depth: depth,
};
// Only highlight the node if it's not being done via API
try {
// Safe way to create a temporary highlight without causing serialization issues
const originalFills = JSON.parse(JSON.stringify(node.fills));
node.fills = [
{
type: "SOLID",
color: { r: 1, g: 0.5, b: 0 },
opacity: 0.3,
},
];
// Promise-based delay instead of setTimeout
await delay(500);
try {
node.fills = originalFills;
} catch (err) {
console.error("Error resetting fills:", err);
}
} catch (highlightErr) {
console.error("Error highlighting text node:", highlightErr);
// Continue anyway, highlighting is just visual feedback
}
textNodes.push(safeTextNode);
} catch (nodeErr) {
console.error("Error processing text node:", nodeErr);
// Skip this node but continue with others
}
}
// Recursively process children of container nodes
if ("children" in node) {
for (const child of node.children) {
await findTextNodes(child, nodePath, depth + 1, textNodes);
}
}
}
// Replace text in a specific node
async function setMultipleTextContents(params) {
const { nodeId, text } = params || {};
const commandId = params.commandId || generateCommandId();
if (!nodeId || !text || !Array.isArray(text)) {
const errorMsg = "Missing required parameters: nodeId and text array";
// Send error progress update
sendProgressUpdate(
commandId,
"set_multiple_text_contents",
"error",
0,
0,
0,
errorMsg,
{ error: errorMsg }
);
throw new Error(errorMsg);
}
console.log(
`Starting text replacement for node: ${nodeId} with ${text.length} text replacements`
);
// Send started progress update
sendProgressUpdate(
commandId,
"set_multiple_text_contents",
"started",
0,
text.length,
0,
`Starting text replacement for ${text.length} nodes`,
{ totalReplacements: text.length }
);
// Define the results array and counters
const results = [];
let successCount = 0;
let failureCount = 0;
// Split text replacements into chunks of 5
const CHUNK_SIZE = 5;
const chunks = [];
for (let i = 0; i < text.length; i += CHUNK_SIZE) {
chunks.push(text.slice(i, i + CHUNK_SIZE));
}
console.log(`Split ${text.length} replacements into ${chunks.length} chunks`);
// Send chunking info update
sendProgressUpdate(
commandId,
"set_multiple_text_contents",
"in_progress",
5, // 5% progress for planning phase
text.length,
0,
`Preparing to replace text in ${text.length} nodes using ${chunks.length} chunks`,
{
totalReplacements: text.length,
chunks: chunks.length,
chunkSize: CHUNK_SIZE,
}
);
// Process each chunk sequentially
for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
const chunk = chunks[chunkIndex];
console.log(
`Processing chunk ${chunkIndex + 1}/${chunks.length} with ${chunk.length
} replacements`
);
// Send chunk processing start update
sendProgressUpdate(
commandId,
"set_multiple_text_contents",
"in_progress",
Math.round(5 + (chunkIndex / chunks.length) * 90), // 5-95% for processing
text.length,
successCount + failureCount,
`Processing text replacements chunk ${chunkIndex + 1}/${chunks.length}`,
{
currentChunk: chunkIndex + 1,
totalChunks: chunks.length,
successCount,
failureCount,
}
);
// Process replacements within a chunk in parallel
const chunkPromises = chunk.map(async (replacement) => {
if (!replacement.nodeId || replacement.text === undefined) {
console.error(`Missing nodeId or text for replacement`);
return {
success: false,
nodeId: replacement.nodeId || "unknown",
error: "Missing nodeId or text in replacement entry",
};
}
try {
console.log(
`Attempting to replace text in node: ${replacement.nodeId}`
);
// Get the text node to update (just to check it exists and get original text)
const textNode = await figma.getNodeByIdAsync(replacement.nodeId);
if (!textNode) {
console.error(`Text node not found: ${replacement.nodeId}`);
return {
success: false,
nodeId: replacement.nodeId,
error: `Node not found: ${replacement.nodeId}`,
};
}
if (textNode.type !== "TEXT") {
console.error(
`Node is not a text node: ${replacement.nodeId} (type: ${textNode.type})`
);
return {
success: false,
nodeId: replacement.nodeId,
error: `Node is not a text node: ${replacement.nodeId} (type: ${textNode.type})`,
};
}
// Save original text for the result
const originalText = textNode.characters;
console.log(`Original text: "${originalText}"`);
console.log(`Will translate to: "${replacement.text}"`);
// Highlight the node before changing text
let originalFills;
try {
// Save original fills for restoration later
originalFills = JSON.parse(JSON.stringify(textNode.fills));
// Apply highlight color (orange with 30% opacity)
textNode.fills = [
{
type: "SOLID",
color: { r: 1, g: 0.5, b: 0 },
opacity: 0.3,
},
];
} catch (highlightErr) {
console.error(
`Error highlighting text node: ${highlightErr.message}`
);
// Continue anyway, highlighting is just visual feedback
}
// Use the existing setTextContent function to handle font loading and text setting
await setTextContent({
nodeId: replacement.nodeId,
text: replacement.text,
});
// Keep highlight for a moment after text change, then restore original fills
if (originalFills) {
try {
// Use delay function for consistent timing
await delay(500);
textNode.fills = originalFills;
} catch (restoreErr) {
console.error(`Error restoring fills: ${restoreErr.message}`);
}
}
console.log(
`Successfully replaced text in node: ${replacement.nodeId}`
);
return {
success: true,
nodeId: replacement.nodeId,
originalText: originalText,
translatedText: replacement.text,
};
} catch (error) {
console.error(
`Error replacing text in node ${replacement.nodeId}: ${error.message}`
);
return {
success: false,
nodeId: replacement.nodeId,
error: `Error applying replacement: ${error.message}`,
};
}
});
// Wait for all replacements in this chunk to complete
const chunkResults = await Promise.all(chunkPromises);
// Process results for this chunk
chunkResults.forEach((result) => {
if (result.success) {
successCount++;
} else {
failureCount++;
}
results.push(result);
});
// Send chunk processing complete update with partial results
sendProgressUpdate(
commandId,
"set_multiple_text_contents",
"in_progress",
Math.round(5 + ((chunkIndex + 1) / chunks.length) * 90), // 5-95% for processing
text.length,
successCount + failureCount,
`Completed chunk ${chunkIndex + 1}/${chunks.length
}. ${successCount} successful, ${failureCount} failed so far.`,
{
currentChunk: chunkIndex + 1,
totalChunks: chunks.length,
successCount,
failureCount,
chunkResults: chunkResults,
}
);
// Add a small delay between chunks to avoid overloading Figma
if (chunkIndex < chunks.length - 1) {
console.log("Pausing between chunks to avoid overloading Figma...");
await delay(1000); // 1 second delay between chunks
}
}
console.log(
`Replacement complete: ${successCount} successful, ${failureCount} failed`
);
// Send completed progress update
sendProgressUpdate(
commandId,
"set_multiple_text_contents",
"completed",
100,
text.length,
successCount + failureCount,
`Text replacement complete: ${successCount} successful, ${failureCount} failed`,
{
totalReplacements: text.length,
replacementsApplied: successCount,
replacementsFailed: failureCount,
completedInChunks: chunks.length,
results: results,
}
);
return {
success: successCount > 0,
nodeId: nodeId,
replacementsApplied: successCount,
replacementsFailed: failureCount,
totalReplacements: text.length,
results: results,
completedInChunks: chunks.length,
commandId,
};
}
// Function to generate simple UUIDs for command IDs
function generateCommandId() {
return (
"cmd_" +
Math.random().toString(36).substring(2, 15) +
Math.random().toString(36).substring(2, 15)
);
}
async function getAnnotations(params) {
try {
const { nodeId, includeCategories = true } = params;
// Get categories first if needed
let categoriesMap = {};
if (includeCategories) {
const categories = await figma.annotations.getAnnotationCategoriesAsync();
categoriesMap = categories.reduce((map, category) => {
map[category.id] = {
id: category.id,
label: category.label,
color: category.color,
isPreset: category.isPreset,
};
return map;
}, {});
}
if (nodeId) {
// Get annotations for a specific node
const node = await figma.getNodeByIdAsync(nodeId);
if (!node) {
throw new Error(`Node not found: ${nodeId}`);
}
if (!("annotations" in node)) {
throw new Error(`Node type ${node.type} does not support annotations`);
}
const result = {
nodeId: node.id,
name: node.name,
annotations: node.annotations || [],
};
if (includeCategories) {
result.categories = Object.values(categoriesMap);
}
return result;
} else {
// Get all annotations in the current page
const annotations = [];
const processNode = async (node) => {
if (
"annotations" in node &&
node.annotations &&
node.annotations.length > 0
) {
annotations.push({
nodeId: node.id,
name: node.name,
annotations: node.annotations,
});
}
if ("children" in node) {
for (const child of node.children) {
await processNode(child);
}
}
};
// Start from current page
await processNode(figma.currentPage);
const result = {
annotatedNodes: annotations,
};
if (includeCategories) {
result.categories = Object.values(categoriesMap);
}
return result;
}
} catch (error) {
console.error("Error in getAnnotations:", error);
throw error;
}
}
async function setAnnotation(params) {
try {
console.log("=== setAnnotation Debug Start ===");
console.log("Input params:", JSON.stringify(params, null, 2));
const { nodeId, annotationId, labelMarkdown, categoryId, properties } =
params;
// Validate required parameters
if (!nodeId) {
console.error("Validation failed: Missing nodeId");
return { success: false, error: "Missing nodeId" };
}
if (!labelMarkdown) {
console.error("Validation failed: Missing labelMarkdown");
return { success: false, error: "Missing labelMarkdown" };
}
console.log("Attempting to get node:", nodeId);
// Get and validate node
const node = await figma.getNodeByIdAsync(nodeId);
console.log("Node lookup result:", {
id: nodeId,
found: !!node,
type: node ? node.type : undefined,
name: node ? node.name : undefined,
hasAnnotations: node ? "annotations" in node : false,
});
if (!node) {
console.error("Node lookup failed:", nodeId);
return { success: false, error: `Node not found: ${nodeId}` };
}
// Validate node supports annotations
if (!("annotations" in node)) {
console.error("Node annotation support check failed:", {
nodeType: node.type,
nodeId: node.id,
});
return {
success: false,
error: `Node type ${node.type} does not support annotations`,
};
}
// Create the annotation object
const newAnnotation = {
labelMarkdown,
};
// Validate and add categoryId if provided
if (categoryId) {
console.log("Adding categoryId to annotation:", categoryId);
newAnnotation.categoryId = categoryId;
}
// Validate and add properties if provided
if (properties && Array.isArray(properties) && properties.length > 0) {
console.log(
"Adding properties to annotation:",
JSON.stringify(properties, null, 2)
);
newAnnotation.properties = properties;
}
// Log current annotations before update
console.log("Current node annotations:", node.annotations);
// Overwrite annotations
console.log(
"Setting new annotation:",
JSON.stringify(newAnnotation, null, 2)
);
node.annotations = [newAnnotation];
// Verify the update
console.log("Updated node annotations:", node.annotations);
console.log("=== setAnnotation Debug End ===");
return {
success: true,
nodeId: node.id,
name: node.name,
annotations: node.annotations,
};
} catch (error) {
console.error("=== setAnnotation Error ===");
console.error("Error details:", {
message: error.message,
stack: error.stack,
params: JSON.stringify(params, null, 2),
});
return { success: false, error: error.message };
}
}
/**
* Scan for nodes with specific types within a node
* @param {Object} params - Parameters object
* @param {string} params.nodeId - ID of the node to scan within
* @param {Array<string>} params.types - Array of node types to find (e.g. ['COMPONENT', 'FRAME'])
* @returns {Object} - Object containing found nodes
*/
async function scanNodesByTypes(params) {
console.log(`Starting to scan nodes by types from node ID: ${params.nodeId}`);
const { nodeId, types = [] } = params || {};
if (!types || types.length === 0) {
throw new Error("No types specified to search for");
}
const node = await figma.getNodeByIdAsync(nodeId);
if (!node) {
throw new Error(`Node with ID ${nodeId} not found`);
}
// Simple implementation without chunking
const matchingNodes = [];
// Send a single progress update to notify start
const commandId = generateCommandId();
sendProgressUpdate(
commandId,
"scan_nodes_by_types",
"started",
0,
1,
0,
`Starting scan of node "${node.name || nodeId}" for types: ${types.join(
", "
)}`,
null
);
// Recursively find nodes with specified types
await findNodesByTypes(node, types, matchingNodes);
// Send completion update
sendProgressUpdate(
commandId,
"scan_nodes_by_types",
"completed",
100,
matchingNodes.length,
matchingNodes.length,
`Scan complete. Found ${matchingNodes.length} matching nodes.`,
{ matchingNodes }
);
return {
success: true,
message: `Found ${matchingNodes.length} matching nodes.`,
count: matchingNodes.length,
matchingNodes: matchingNodes,
searchedTypes: types,
};
}
/**
* Helper function to recursively find nodes with specific types
* @param {SceneNode} node - The root node to start searching from
* @param {Array<string>} types - Array of node types to find
* @param {Array} matchingNodes - Array to store found nodes
*/
async function findNodesByTypes(node, types, matchingNodes = []) {
// Skip invisible nodes
if (node.visible === false) return;
// Check if this node is one of the specified types
if (types.includes(node.type)) {
// Create a minimal representation with just ID, type and bbox
matchingNodes.push({
id: node.id,
name: node.name || `Unnamed ${node.type}`,
type: node.type,
// Basic bounding box info
bbox: {
x: typeof node.x === "number" ? node.x : 0,
y: typeof node.y === "number" ? node.y : 0,
width: typeof node.width === "number" ? node.width : 0,
height: typeof node.height === "number" ? node.height : 0,
},
});
}
// Recursively process children of container nodes
if ("children" in node) {
for (const child of node.children) {
await findNodesByTypes(child, types, matchingNodes);
}
}
}
// Set multiple annotations with async progress updates
async function setMultipleAnnotations(params) {
console.log("=== setMultipleAnnotations Debug Start ===");
console.log("Input params:", JSON.stringify(params, null, 2));
const { nodeId, annotations } = params;
if (!annotations || annotations.length === 0) {
console.error("Validation failed: No annotations provided");
return { success: false, error: "No annotations provided" };
}
console.log(
`Processing ${annotations.length} annotations for node ${nodeId}`
);
const results = [];
let successCount = 0;
let failureCount = 0;
// Process annotations sequentially
for (let i = 0; i < annotations.length; i++) {
const annotation = annotations[i];
console.log(
`\nProcessing annotation ${i + 1}/${annotations.length}:`,
JSON.stringify(annotation, null, 2)
);
try {
console.log("Calling setAnnotation with params:", {
nodeId: annotation.nodeId,
labelMarkdown: annotation.labelMarkdown,
categoryId: annotation.categoryId,
properties: annotation.properties,
});
const result = await setAnnotation({
nodeId: annotation.nodeId,
labelMarkdown: annotation.labelMarkdown,
categoryId: annotation.categoryId,
properties: annotation.properties,
});
console.log("setAnnotation result:", JSON.stringify(result, null, 2));
if (result.success) {
successCount++;
results.push({ success: true, nodeId: annotation.nodeId });
console.log(`✓ Annotation ${i + 1} applied successfully`);
} else {
failureCount++;
results.push({
success: false,
nodeId: annotation.nodeId,
error: result.error,
});
console.error(`✗ Annotation ${i + 1} failed:`, result.error);
}
} catch (error) {
failureCount++;
const errorResult = {
success: false,
nodeId: annotation.nodeId,
error: error.message,
};
results.push(errorResult);
console.error(`✗ Annotation ${i + 1} failed with error:`, error);
console.error("Error details:", {
message: error.message,
stack: error.stack,
});
}
}
const summary = {
success: successCount > 0,
annotationsApplied: successCount,
annotationsFailed: failureCount,
totalAnnotations: annotations.length,
results: results,
};
console.log("\n=== setMultipleAnnotations Summary ===");
console.log(JSON.stringify(summary, null, 2));
console.log("=== setMultipleAnnotations Debug End ===");
return summary;
}
async function deleteMultipleNodes(params) {
const { nodeIds } = params || {};
const commandId = generateCommandId();
if (!nodeIds || !Array.isArray(nodeIds) || nodeIds.length === 0) {
const errorMsg = "Missing or invalid nodeIds parameter";
sendProgressUpdate(
commandId,
"delete_multiple_nodes",
"error",
0,
0,
0,
errorMsg,
{ error: errorMsg }
);
throw new Error(errorMsg);
}
console.log(`Starting deletion of ${nodeIds.length} nodes`);
// Send started progress update
sendProgressUpdate(
commandId,
"delete_multiple_nodes",
"started",
0,
nodeIds.length,
0,
`Starting deletion of ${nodeIds.length} nodes`,
{ totalNodes: nodeIds.length }
);
const results = [];
let successCount = 0;
let failureCount = 0;
// Process nodes in chunks of 5 to avoid overwhelming Figma
const CHUNK_SIZE = 5;
const chunks = [];
for (let i = 0; i < nodeIds.length; i += CHUNK_SIZE) {
chunks.push(nodeIds.slice(i, i + CHUNK_SIZE));
}
console.log(`Split ${nodeIds.length} deletions into ${chunks.length} chunks`);
// Send chunking info update
sendProgressUpdate(
commandId,
"delete_multiple_nodes",
"in_progress",
5,
nodeIds.length,
0,
`Preparing to delete ${nodeIds.length} nodes using ${chunks.length} chunks`,
{
totalNodes: nodeIds.length,
chunks: chunks.length,
chunkSize: CHUNK_SIZE,
}
);
// Process each chunk sequentially
for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
const chunk = chunks[chunkIndex];
console.log(
`Processing chunk ${chunkIndex + 1}/${chunks.length} with ${chunk.length
} nodes`
);
// Send chunk processing start update
sendProgressUpdate(
commandId,
"delete_multiple_nodes",
"in_progress",
Math.round(5 + (chunkIndex / chunks.length) * 90),
nodeIds.length,
successCount + failureCount,
`Processing deletion chunk ${chunkIndex + 1}/${chunks.length}`,
{
currentChunk: chunkIndex + 1,
totalChunks: chunks.length,
successCount,
failureCount,
}
);
// Process deletions within a chunk in parallel
const chunkPromises = chunk.map(async (nodeId) => {
try {
const node = await figma.getNodeByIdAsync(nodeId);
if (!node) {
console.error(`Node not found: ${nodeId}`);
return {
success: false,
nodeId: nodeId,
error: `Node not found: ${nodeId}`,
};
}
// Save node info before deleting
const nodeInfo = {
id: node.id,
name: node.name,
type: node.type,
};
// Delete the node
node.remove();
console.log(`Successfully deleted node: ${nodeId}`);
return {
success: true,
nodeId: nodeId,
nodeInfo: nodeInfo,
};
} catch (error) {
console.error(`Error deleting node ${nodeId}: ${error.message}`);
return {
success: false,
nodeId: nodeId,
error: error.message,
};
}
});
// Wait for all deletions in this chunk to complete
const chunkResults = await Promise.all(chunkPromises);
// Process results for this chunk
chunkResults.forEach((result) => {
if (result.success) {
successCount++;
} else {
failureCount++;
}
results.push(result);
});
// Send chunk processing complete update
sendProgressUpdate(
commandId,
"delete_multiple_nodes",
"in_progress",
Math.round(5 + ((chunkIndex + 1) / chunks.length) * 90),
nodeIds.length,
successCount + failureCount,
`Completed chunk ${chunkIndex + 1}/${chunks.length
}. ${successCount} successful, ${failureCount} failed so far.`,
{
currentChunk: chunkIndex + 1,
totalChunks: chunks.length,
successCount,
failureCount,
chunkResults: chunkResults,
}
);
// Add a small delay between chunks
if (chunkIndex < chunks.length - 1) {
console.log("Pausing between chunks...");
await delay(1000);
}
}
console.log(
`Deletion complete: ${successCount} successful, ${failureCount} failed`
);
// Send completed progress update
sendProgressUpdate(
commandId,
"delete_multiple_nodes",
"completed",
100,
nodeIds.length,
successCount + failureCount,
`Node deletion complete: ${successCount} successful, ${failureCount} failed`,
{
totalNodes: nodeIds.length,
nodesDeleted: successCount,
nodesFailed: failureCount,
completedInChunks: chunks.length,
results: results,
}
);
return {
success: successCount > 0,
nodesDeleted: successCount,
nodesFailed: failureCount,
totalNodes: nodeIds.length,
results: results,
completedInChunks: chunks.length,
commandId,
};
}
// Implementation for getInstanceOverrides function
async function getInstanceOverrides(instanceNode = null) {
console.log("=== getInstanceOverrides called ===");
let sourceInstance = null;
// Check if an instance node was passed directly
if (instanceNode) {
console.log("Using provided instance node");
// Validate that the provided node is an instance
if (instanceNode.type !== "INSTANCE") {
console.error("Provided node is not an instance");
figma.notify("Provided node is not a component instance");
return { success: false, message: "Provided node is not a component instance" };
}
sourceInstance = instanceNode;
} else {
// No node provided, use selection
console.log("No node provided, using current selection");
// Get the current selection
const selection = figma.currentPage.selection;
// Check if there's anything selected
if (selection.length === 0) {
console.log("No nodes selected");
figma.notify("Please select at least one instance");
return { success: false, message: "No nodes selected" };
}
// Filter for instances in the selection
const instances = selection.filter(node => node.type === "INSTANCE");
if (instances.length === 0) {
console.log("No instances found in selection");
figma.notify("Please select at least one component instance");
return { success: false, message: "No instances found in selection" };
}
// Take the first instance from the selection
sourceInstance = instances[0];
}
try {
console.log(`Getting instance information:`);
console.log(sourceInstance);
// Get component overrides and main component
const overrides = sourceInstance.overrides || [];
console.log(` Raw Overrides:`, overrides);
// Get main component
const mainComponent = await sourceInstance.getMainComponentAsync();
if (!mainComponent) {
console.error("Failed to get main component");
figma.notify("Failed to get main component");
return { success: false, message: "Failed to get main component" };
}
// return data to MCP server
const returnData = {
success: true,
message: `Got component information from "${sourceInstance.name}" for overrides.length: ${overrides.length}`,
sourceInstanceId: sourceInstance.id,
mainComponentId: mainComponent.id,
overridesCount: overrides.length
};
console.log("Data to return to MCP server:", returnData);
figma.notify(`Got component information from "${sourceInstance.name}"`);
return returnData;
} catch (error) {
console.error("Error in getInstanceOverrides:", error);
figma.notify(`Error: ${error.message}`);
return {
success: false,
message: `Error: ${error.message}`
};
}
}
/**
* Helper function to validate and get target instances
* @param {string[]} targetNodeIds - Array of instance node IDs
* @returns {instanceNode[]} targetInstances - Array of target instances
*/
async function getValidTargetInstances(targetNodeIds) {
let targetInstances = [];
// Handle array of instances or single instance
if (Array.isArray(targetNodeIds)) {
if (targetNodeIds.length === 0) {
return { success: false, message: "No instances provided" };
}
for (const targetNodeId of targetNodeIds) {
const targetNode = await figma.getNodeByIdAsync(targetNodeId);
if (targetNode && targetNode.type === "INSTANCE") {
targetInstances.push(targetNode);
}
}
if (targetInstances.length === 0) {
return { success: false, message: "No valid instances provided" };
}
} else {
return { success: false, message: "Invalid target node IDs provided" };
}
return { success: true, message: "Valid target instances provided", targetInstances };
}
/**
* Helper function to validate and get saved override data
* @param {string} sourceInstanceId - Source instance ID
* @returns {Promise<Object>} - Validation result with source instance data or error
*/
async function getSourceInstanceData(sourceInstanceId) {
if (!sourceInstanceId) {
return { success: false, message: "Missing source instance ID" };
}
// Get source instance by ID
const sourceInstance = await figma.getNodeByIdAsync(sourceInstanceId);
if (!sourceInstance) {
return {
success: false,
message: "Source instance not found. The original instance may have been deleted."
};
}
// Verify it's an instance
if (sourceInstance.type !== "INSTANCE") {
return {
success: false,
message: "Source node is not a component instance."
};
}
// Get main component
const mainComponent = await sourceInstance.getMainComponentAsync();
if (!mainComponent) {
return {
success: false,
message: "Failed to get main component from source instance."
};
}
return {
success: true,
sourceInstance,
mainComponent,
overrides: sourceInstance.overrides || []
};
}
/**
* Sets saved overrides to the selected component instance(s)
* @param {InstanceNode[] | null} targetInstances - Array of instance nodes to set overrides to
* @param {Object} sourceResult - Source instance data from getSourceInstanceData
* @returns {Promise<Object>} - Result of the set operation
*/
async function setInstanceOverrides(targetInstances, sourceResult) {
try {
const { sourceInstance, mainComponent, overrides } = sourceResult;
console.log(`Processing ${targetInstances.length} instances with ${overrides.length} overrides`);
console.log(`Source instance: ${sourceInstance.id}, Main component: ${mainComponent.id}`);
console.log(`Overrides:`, overrides);
// Process all instances
const results = [];
let totalAppliedCount = 0;
for (const targetInstance of targetInstances) {
try {
// // Skip if trying to apply to the source instance itself
// if (targetInstance.id === sourceInstance.id) {
// console.log(`Skipping source instance itself: ${targetInstance.id}`);
// results.push({
// success: false,
// instanceId: targetInstance.id,
// instanceName: targetInstance.name,
// message: "This is the source instance itself, skipping"
// });
// continue;
// }
// Swap component
try {
targetInstance.swapComponent(mainComponent);
console.log(`Swapped component for instance "${targetInstance.name}"`);
} catch (error) {
console.error(`Error swapping component for instance "${targetInstance.name}":`, error);
results.push({
success: false,
instanceId: targetInstance.id,
instanceName: targetInstance.name,
message: `Error: ${error.message}`
});
}
// Prepare overrides by replacing node IDs
let appliedCount = 0;
// Apply each override
for (const override of overrides) {
// Skip if no ID or overriddenFields
if (!override.id || !override.overriddenFields || override.overriddenFields.length === 0) {
continue;
}
// Replace source instance ID with target instance ID in the node path
const overrideNodeId = override.id.replace(sourceInstance.id, targetInstance.id);
const overrideNode = await figma.getNodeByIdAsync(overrideNodeId);
if (!overrideNode) {
console.log(`Override node not found: ${overrideNodeId}`);
continue;
}
// Get source node to copy properties from
const sourceNode = await figma.getNodeByIdAsync(override.id);
if (!sourceNode) {
console.log(`Source node not found: ${override.id}`);
continue;
}
// Apply each overridden field
let fieldApplied = false;
for (const field of override.overriddenFields) {
try {
if (field === "componentProperties") {
// Apply component properties
if (sourceNode.componentProperties && overrideNode.componentProperties) {
const properties = {};
for (const key in sourceNode.componentProperties) {
// if INSTANCE_SWAP use id, otherwise use value
if (sourceNode.componentProperties[key].type === 'INSTANCE_SWAP') {
properties[key] = sourceNode.componentProperties[key].value;
} else {
properties[key] = sourceNode.componentProperties[key].value;
}
}
overrideNode.setProperties(properties);
fieldApplied = true;
}
} else if (field === "characters" && overrideNode.type === "TEXT") {
// For text nodes, need to load fonts first
await figma.loadFontAsync(overrideNode.fontName);
overrideNode.characters = sourceNode.characters;
fieldApplied = true;
} else if (field in overrideNode) {
// Direct property assignment
overrideNode[field] = sourceNode[field];
fieldApplied = true;
}
} catch (fieldError) {
console.error(`Error applying field ${field}:`, fieldError);
}
}
if (fieldApplied) {
appliedCount++;
}
}
if (appliedCount > 0) {
totalAppliedCount += appliedCount;
results.push({
success: true,
instanceId: targetInstance.id,
instanceName: targetInstance.name,
appliedCount
});
console.log(`Applied ${appliedCount} overrides to "${targetInstance.name}"`);
} else {
results.push({
success: false,
instanceId: targetInstance.id,
instanceName: targetInstance.name,
message: "No overrides were applied"
});
}
} catch (instanceError) {
console.error(`Error processing instance "${targetInstance.name}":`, instanceError);
results.push({
success: false,
instanceId: targetInstance.id,
instanceName: targetInstance.name,
message: `Error: ${instanceError.message}`
});
}
}
// Return results
if (totalAppliedCount > 0) {
const instanceCount = results.filter(r => r.success).length;
const message = `Applied ${totalAppliedCount} overrides to ${instanceCount} instances`;
figma.notify(message);
return {
success: true,
message,
totalCount: totalAppliedCount,
results
};
} else {
const message = "No overrides applied to any instance";
figma.notify(message);
return { success: false, message, results };
}
} catch (error) {
console.error("Error in setInstanceOverrides:", error);
const message = `Error: ${error.message}`;
figma.notify(message);
return { success: false, message };
}
}
async function setLayoutMode(params) {
const { nodeId, layoutMode = "NONE", layoutWrap = "NO_WRAP" } = params || {};
// Get the target node
const node = await figma.getNodeByIdAsync(nodeId);
if (!node) {
throw new Error(`Node with ID ${nodeId} not found`);
}
// Check if node is a frame or component that supports layoutMode
if (
node.type !== "FRAME" &&
node.type !== "COMPONENT" &&
node.type !== "COMPONENT_SET" &&
node.type !== "INSTANCE"
) {
throw new Error(`Node type ${node.type} does not support layoutMode`);
}
// Set layout mode
node.layoutMode = layoutMode;
// Set layoutWrap if applicable
if (layoutMode !== "NONE") {
node.layoutWrap = layoutWrap;
}
return {
id: node.id,
name: node.name,
layoutMode: node.layoutMode,
layoutWrap: node.layoutWrap,
};
}
async function setPadding(params) {
const { nodeId, paddingTop, paddingRight, paddingBottom, paddingLeft } =
params || {};
// Get the target node
const node = await figma.getNodeByIdAsync(nodeId);
if (!node) {
throw new Error(`Node with ID ${nodeId} not found`);
}
// Check if node is a frame or component that supports padding
if (
node.type !== "FRAME" &&
node.type !== "COMPONENT" &&
node.type !== "COMPONENT_SET" &&
node.type !== "INSTANCE"
) {
throw new Error(`Node type ${node.type} does not support padding`);
}
// Check if the node has auto-layout enabled
if (node.layoutMode === "NONE") {
throw new Error(
"Padding can only be set on auto-layout frames (layoutMode must not be NONE)"
);
}
// Set padding values if provided
if (paddingTop !== undefined) node.paddingTop = paddingTop;
if (paddingRight !== undefined) node.paddingRight = paddingRight;
if (paddingBottom !== undefined) node.paddingBottom = paddingBottom;
if (paddingLeft !== undefined) node.paddingLeft = paddingLeft;
return {
id: node.id,
name: node.name,
paddingTop: node.paddingTop,
paddingRight: node.paddingRight,
paddingBottom: node.paddingBottom,
paddingLeft: node.paddingLeft,
};
}
async function setAxisAlign(params) {
const { nodeId, primaryAxisAlignItems, counterAxisAlignItems } = params || {};
// Get the target node
const node = await figma.getNodeByIdAsync(nodeId);
if (!node) {
throw new Error(`Node with ID ${nodeId} not found`);
}
// Check if node is a frame or component that supports axis alignment
if (
node.type !== "FRAME" &&
node.type !== "COMPONENT" &&
node.type !== "COMPONENT_SET" &&
node.type !== "INSTANCE"
) {
throw new Error(`Node type ${node.type} does not support axis alignment`);
}
// Check if the node has auto-layout enabled
if (node.layoutMode === "NONE") {
throw new Error(
"Axis alignment can only be set on auto-layout frames (layoutMode must not be NONE)"
);
}
// Validate and set primaryAxisAlignItems if provided
if (primaryAxisAlignItems !== undefined) {
if (
!["MIN", "MAX", "CENTER", "SPACE_BETWEEN"].includes(primaryAxisAlignItems)
) {
throw new Error(
"Invalid primaryAxisAlignItems value. Must be one of: MIN, MAX, CENTER, SPACE_BETWEEN"
);
}
node.primaryAxisAlignItems = primaryAxisAlignItems;
}
// Validate and set counterAxisAlignItems if provided
if (counterAxisAlignItems !== undefined) {
if (!["MIN", "MAX", "CENTER", "BASELINE"].includes(counterAxisAlignItems)) {
throw new Error(
"Invalid counterAxisAlignItems value. Must be one of: MIN, MAX, CENTER, BASELINE"
);
}
// BASELINE is only valid for horizontal layout
if (
counterAxisAlignItems === "BASELINE" &&
node.layoutMode !== "HORIZONTAL"
) {
throw new Error(
"BASELINE alignment is only valid for horizontal auto-layout frames"
);
}
node.counterAxisAlignItems = counterAxisAlignItems;
}
return {
id: node.id,
name: node.name,
primaryAxisAlignItems: node.primaryAxisAlignItems,
counterAxisAlignItems: node.counterAxisAlignItems,
layoutMode: node.layoutMode,
};
}
async function setLayoutSizing(params) {
const { nodeId, layoutSizingHorizontal, layoutSizingVertical } = params || {};
// Get the target node
const node = await figma.getNodeByIdAsync(nodeId);
if (!node) {
throw new Error(`Node with ID ${nodeId} not found`);
}
// Check if node is a frame or component that supports layout sizing
if (
node.type !== "FRAME" &&
node.type !== "COMPONENT" &&
node.type !== "COMPONENT_SET" &&
node.type !== "INSTANCE"
) {
throw new Error(`Node type ${node.type} does not support layout sizing`);
}
// Check if the node has auto-layout enabled
if (node.layoutMode === "NONE") {
throw new Error(
"Layout sizing can only be set on auto-layout frames (layoutMode must not be NONE)"
);
}
// Validate and set layoutSizingHorizontal if provided
if (layoutSizingHorizontal !== undefined) {
if (!["FIXED", "HUG", "FILL"].includes(layoutSizingHorizontal)) {
throw new Error(
"Invalid layoutSizingHorizontal value. Must be one of: FIXED, HUG, FILL"
);
}
// HUG is only valid on auto-layout frames and text nodes
if (
layoutSizingHorizontal === "HUG" &&
!["FRAME", "TEXT"].includes(node.type)
) {
throw new Error(
"HUG sizing is only valid on auto-layout frames and text nodes"
);
}
// FILL is only valid on auto-layout children
if (
layoutSizingHorizontal === "FILL" &&
(!node.parent || node.parent.layoutMode === "NONE")
) {
throw new Error("FILL sizing is only valid on auto-layout children");
}
node.layoutSizingHorizontal = layoutSizingHorizontal;
}
// Validate and set layoutSizingVertical if provided
if (layoutSizingVertical !== undefined) {
if (!["FIXED", "HUG", "FILL"].includes(layoutSizingVertical)) {
throw new Error(
"Invalid layoutSizingVertical value. Must be one of: FIXED, HUG, FILL"
);
}
// HUG is only valid on auto-layout frames and text nodes
if (
layoutSizingVertical === "HUG" &&
!["FRAME", "TEXT"].includes(node.type)
) {
throw new Error(
"HUG sizing is only valid on auto-layout frames and text nodes"
);
}
// FILL is only valid on auto-layout children
if (
layoutSizingVertical === "FILL" &&
(!node.parent || node.parent.layoutMode === "NONE")
) {
throw new Error("FILL sizing is only valid on auto-layout children");
}
node.layoutSizingVertical = layoutSizingVertical;
}
return {
id: node.id,
name: node.name,
layoutSizingHorizontal: node.layoutSizingHorizontal,
layoutSizingVertical: node.layoutSizingVertical,
layoutMode: node.layoutMode,
};
}
async function setItemSpacing(params) {
const { nodeId, itemSpacing } = params || {};
// Get the target node
const node = await figma.getNodeByIdAsync(nodeId);
if (!node) {
throw new Error(`Node with ID ${nodeId} not found`);
}
// Check if node is a frame or component that supports item spacing
if (
node.type !== "FRAME" &&
node.type !== "COMPONENT" &&
node.type !== "COMPONENT_SET" &&
node.type !== "INSTANCE"
) {
throw new Error(`Node type ${node.type} does not support item spacing`);
}
// Check if the node has auto-layout enabled
if (node.layoutMode === "NONE") {
throw new Error(
"Item spacing can only be set on auto-layout frames (layoutMode must not be NONE)"
);
}
// Set item spacing
if (itemSpacing !== undefined) {
if (typeof itemSpacing !== "number") {
throw new Error("Item spacing must be a number");
}
node.itemSpacing = itemSpacing;
}
return {
id: node.id,
name: node.name,
itemSpacing: node.itemSpacing,
layoutMode: node.layoutMode,
};
}
async function setDefaultConnector(params) {
const { connectorId } = params || {};
// If connectorId is provided, search and set by that ID (do not check existing storage)
if (connectorId) {
// Get node by specified ID
const node = await figma.getNodeByIdAsync(connectorId);
if (!node) {
throw new Error(`Connector node not found with ID: ${connectorId}`);
}
// Check node type
if (node.type !== 'CONNECTOR') {
throw new Error(`Node is not a connector: ${connectorId}`);
}
// Set the found connector as the default connector
await figma.clientStorage.setAsync('defaultConnectorId', connectorId);
return {
success: true,
message: `Default connector set to: ${connectorId}`,
connectorId: connectorId
};
}
// If connectorId is not provided, check existing storage
else {
// Check if there is an existing default connector in client storage
try {
const existingConnectorId = await figma.clientStorage.getAsync('defaultConnectorId');
// If there is an existing connector ID, check if the node is still valid
if (existingConnectorId) {
try {
const existingConnector = await figma.getNodeByIdAsync(existingConnectorId);
// If the stored connector still exists and is of type CONNECTOR
if (existingConnector && existingConnector.type === 'CONNECTOR') {
return {
success: true,
message: `Default connector is already set to: ${existingConnectorId}`,
connectorId: existingConnectorId,
exists: true
};
}
// The stored connector is no longer valid - find a new connector
else {
console.log(`Stored connector ID ${existingConnectorId} is no longer valid, finding a new connector...`);
}
} catch (error) {
console.log(`Error finding stored connector: ${error.message}. Will try to set a new one.`);
}
}
} catch (error) {
console.log(`Error checking for existing connector: ${error.message}`);
}
// If there is no stored default connector or it is invalid, find one in the current page
try {
// Find CONNECTOR type nodes in the current page
const currentPageConnectors = figma.currentPage.findAllWithCriteria({ types: ['CONNECTOR'] });
if (currentPageConnectors && currentPageConnectors.length > 0) {
// Use the first connector found
const foundConnector = currentPageConnectors[0];
const autoFoundId = foundConnector.id;
// Set the found connector as the default connector
await figma.clientStorage.setAsync('defaultConnectorId', autoFoundId);
return {
success: true,
message: `Automatically found and set default connector to: ${autoFoundId}`,
connectorId: autoFoundId,
autoSelected: true
};
} else {
// If no connector is found in the current page, show a guide message
throw new Error('No connector found in the current page. Please create a connector in Figma first or specify a connector ID.');
}
} catch (error) {
// Error occurred while running findAllWithCriteria
throw new Error(`Failed to find a connector: ${error.message}`);
}
}
}
async function createCursorNode(targetNodeId) {
const svgString = `<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16 8V35.2419L22 28.4315L27 39.7823C27 39.7823 28.3526 40.2722 29 39.7823C29.6474 39.2924 30.2913 38.3057 30 37.5121C28.6247 33.7654 25 26.1613 25 26.1613H32L16 8Z" fill="#202125" />
</svg>`;
try {
const targetNode = await figma.getNodeByIdAsync(targetNodeId);
if (!targetNode) throw new Error("Target node not found");
// The targetNodeId has semicolons since it is a nested node.
// So we need to get the parent node ID from the target node ID and check if we can appendChild to it or not.
let parentNodeId = targetNodeId.includes(';')
? targetNodeId.split(';')[0]
: targetNodeId;
if (!parentNodeId) throw new Error("Could not determine parent node ID");
// Find the parent node to append cursor node as child
let parentNode = await figma.getNodeByIdAsync(parentNodeId);
if (!parentNode) throw new Error("Parent node not found");
// If the parent node is not eligible to appendChild, set the parentNode to the parent of the parentNode
if (parentNode.type === 'INSTANCE' || parentNode.type === 'COMPONENT' || parentNode.type === 'COMPONENT_SET') {
parentNode = parentNode.parent;
if (!parentNode) throw new Error("Parent node not found");
}
// Create the cursor node
const importedNode = await figma.createNodeFromSvg(svgString);
if (!importedNode || !importedNode.id) {
throw new Error("Failed to create imported cursor node");
}
importedNode.name = "TTF_Connector / Mouse Cursor";
importedNode.resize(48, 48);
const cursorNode = importedNode.findOne(node => node.type === 'VECTOR');
if (cursorNode) {
cursorNode.fills = [{
type: 'SOLID',
color: { r: 0, g: 0, b: 0 },
opacity: 1
}];
cursorNode.strokes = [{
type: 'SOLID',
color: { r: 1, g: 1, b: 1 },
opacity: 1
}];
cursorNode.strokeWeight = 2;
cursorNode.strokeAlign = 'OUTSIDE';
cursorNode.effects = [{
type: "DROP_SHADOW",
color: { r: 0, g: 0, b: 0, a: 0.3 },
offset: { x: 1, y: 1 },
radius: 2,
spread: 0,
visible: true,
blendMode: "NORMAL"
}];
}
// Append the cursor node to the parent node
parentNode.appendChild(importedNode);
// if the parentNode has auto-layout enabled, set the layoutPositioning to ABSOLUTE
if ('layoutMode' in parentNode && parentNode.layoutMode !== 'NONE') {
importedNode.layoutPositioning = 'ABSOLUTE';
}
// Adjust the importedNode's position to the targetNode's position
if (
targetNode.absoluteBoundingBox &&
parentNode.absoluteBoundingBox
) {
// if the targetNode has absoluteBoundingBox, set the importedNode's absoluteBoundingBox to the targetNode's absoluteBoundingBox
console.log('targetNode.absoluteBoundingBox', targetNode.absoluteBoundingBox);
console.log('parentNode.absoluteBoundingBox', parentNode.absoluteBoundingBox);
importedNode.x = targetNode.absoluteBoundingBox.x - parentNode.absoluteBoundingBox.x + targetNode.absoluteBoundingBox.width / 2 - 48 / 2
importedNode.y = targetNode.absoluteBoundingBox.y - parentNode.absoluteBoundingBox.y + targetNode.absoluteBoundingBox.height / 2 - 48 / 2;
} else if (
'x' in targetNode && 'y' in targetNode && 'width' in targetNode && 'height' in targetNode) {
// if the targetNode has x, y, width, height, calculate center based on relative position
console.log('targetNode.x/y/width/height', targetNode.x, targetNode.y, targetNode.width, targetNode.height);
importedNode.x = targetNode.x + targetNode.width / 2 - 48 / 2;
importedNode.y = targetNode.y + targetNode.height / 2 - 48 / 2;
} else {
// Fallback: Place at top-left of target if possible, otherwise at (0,0) relative to parent
if ('x' in targetNode && 'y' in targetNode) {
console.log('Fallback to targetNode x/y');
importedNode.x = targetNode.x;
importedNode.y = targetNode.y;
} else {
console.log('Fallback to (0,0)');
importedNode.x = 0;
importedNode.y = 0;
}
}
// get the importedNode ID and the importedNode
console.log('importedNode', importedNode);
return { id: importedNode.id, node: importedNode };
} catch (error) {
console.error("Error creating cursor from SVG:", error);
return { id: null, node: null, error: error.message };
}
}
async function createConnections(params) {
if (!params || !params.connections || !Array.isArray(params.connections)) {
throw new Error('Missing or invalid connections parameter');
}
const { connections } = params;
// Command ID for progress tracking
const commandId = generateCommandId();
sendProgressUpdate(
commandId,
"create_connections",
"started",
0,
connections.length,
0,
`Starting to create ${connections.length} connections`
);
// Get default connector ID from client storage
const defaultConnectorId = await figma.clientStorage.getAsync('defaultConnectorId');
if (!defaultConnectorId) {
throw new Error('No default connector set. Please try one of the following options to create connections:\n1. Create a connector in FigJam and copy/paste it to your current page, then run the "set_default_connector" command.\n2. Select an existing connector on the current page, then run the "set_default_connector" command.');
}
// Get the default connector
const defaultConnector = await figma.getNodeByIdAsync(defaultConnectorId);
if (!defaultConnector) {
throw new Error(`Default connector not found with ID: ${defaultConnectorId}`);
}
if (defaultConnector.type !== 'CONNECTOR') {
throw new Error(`Node is not a connector: ${defaultConnectorId}`);
}
// Results array for connection creation
const results = [];
let processedCount = 0;
const totalCount = connections.length;
// Preload fonts (used for text if provided)
let fontLoaded = false;
for (let i = 0; i < connections.length; i++) {
try {
const { startNodeId: originalStartId, endNodeId: originalEndId, text } = connections[i];
let startId = originalStartId;
let endId = originalEndId;
// Check and potentially replace start node ID
if (startId.includes(';')) {
console.log(`Nested start node detected: ${startId}. Creating cursor node.`);
const cursorResult = await createCursorNode(startId);
if (!cursorResult || !cursorResult.id) {
throw new Error(`Failed to create cursor node for nested start node: ${startId}`);
}
startId = cursorResult.id;
}
const startNode = await figma.getNodeByIdAsync(startId);
if (!startNode) throw new Error(`Start node not found with ID: ${startId}`);
// Check and potentially replace end node ID
if (endId.includes(';')) {
console.log(`Nested end node detected: ${endId}. Creating cursor node.`);
const cursorResult = await createCursorNode(endId);
if (!cursorResult || !cursorResult.id) {
throw new Error(`Failed to create cursor node for nested end node: ${endId}`);
}
endId = cursorResult.id;
}
const endNode = await figma.getNodeByIdAsync(endId);
if (!endNode) throw new Error(`End node not found with ID: ${endId}`);
// Clone the default connector
const clonedConnector = defaultConnector.clone();
// Update connector name using potentially replaced node names
clonedConnector.name = `TTF_Connector/${startNode.id}/${endNode.id}`;
// Set start and end points using potentially replaced IDs
clonedConnector.connectorStart = {
endpointNodeId: startId,
magnet: 'AUTO'
};
clonedConnector.connectorEnd = {
endpointNodeId: endId,
magnet: 'AUTO'
};
// Add text (if provided)
if (text) {
try {
// Try to load the necessary fonts
try {
// First check if default connector has font and use the same
if (defaultConnector.text && defaultConnector.text.fontName) {
const fontName = defaultConnector.text.fontName;
await figma.loadFontAsync(fontName);
clonedConnector.text.fontName = fontName;
} else {
// Try default Inter font
await figma.loadFontAsync({ family: "Inter", style: "Regular" });
}
} catch (fontError) {
// If first font load fails, try another font style
try {
await figma.loadFontAsync({ family: "Inter", style: "Medium" });
} catch (mediumFontError) {
// If second font fails, try system font
try {
await figma.loadFontAsync({ family: "System", style: "Regular" });
} catch (systemFontError) {
// If all font loading attempts fail, throw error
throw new Error(`Failed to load any font: ${fontError.message}`);
}
}
}
// Set the text
clonedConnector.text.characters = text;
} catch (textError) {
console.error("Error setting text:", textError);
// Continue with connection even if text setting fails
results.push({
id: clonedConnector.id,
startNodeId: startNodeId,
endNodeId: endNodeId,
text: "",
textError: textError.message
});
// Continue to next connection
continue;
}
}
// Add to results (using the *original* IDs for reference if needed)
results.push({
id: clonedConnector.id,
originalStartNodeId: originalStartId,
originalEndNodeId: originalEndId,
usedStartNodeId: startId, // ID actually used for connection
usedEndNodeId: endId, // ID actually used for connection
text: text || ""
});
// Update progress
processedCount++;
sendProgressUpdate(
commandId,
"create_connections",
"in_progress",
processedCount / totalCount,
totalCount,
processedCount,
`Created connection ${processedCount}/${totalCount}`
);
} catch (error) {
console.error("Error creating connection", error);
// Continue processing remaining connections even if an error occurs
processedCount++;
sendProgressUpdate(
commandId,
"create_connections",
"in_progress",
processedCount / totalCount,
totalCount,
processedCount,
`Error creating connection: ${error.message}`
);
results.push({
error: error.message,
connectionInfo: connections[i]
});
}
}
// Completion update
sendProgressUpdate(
commandId,
"create_connections",
"completed",
1,
totalCount,
totalCount,
`Completed creating ${results.length} connections`
);
return {
success: true,
count: results.length,
connections: results
};
}
// Listen for selection changes in Figma and notify UI
figma.on("selectionchange", async () => {
const selection = await getSelection();
figma.ui.postMessage({ type: "selection-info", selection });
});